Buka kode yang tangguh dan type-safe di JavaScript dan TypeScript dengan type guard pencocokan pola, discriminated union, dan pemeriksaan kelengkapan. Cegah kesalahan runtime.
Type Guard Pencocokan Pola JavaScript: Panduan untuk Pencocokan Pola Type-Safe
Dalam dunia pengembangan perangkat lunak modern, mengelola struktur data yang kompleks adalah tantangan sehari-hari. Baik Anda menangani respons API, mengelola state aplikasi, atau memproses event pengguna, Anda sering berurusan dengan data yang dapat memiliki salah satu dari beberapa bentuk yang berbeda. Pendekatan tradisional menggunakan pernyataan if-else bersarang atau kasus switch dasar sering kali bertele-tele, rentan kesalahan, dan menjadi sarang bagi kesalahan runtime. Bagaimana jika compiler bisa menjadi jaring pengaman Anda, memastikan Anda telah menangani setiap skenario yang mungkin terjadi?
Di sinilah kekuatan pencocokan pola type-safe berperan. Dengan meminjam konsep dari bahasa pemrograman fungsional seperti F#, OCaml, dan Rust, serta memanfaatkan sistem tipe TypeScript yang kuat, kita dapat menulis kode yang tidak hanya lebih ekspresif dan mudah dibaca tetapi juga secara fundamental lebih aman. Artikel ini adalah pembahasan mendalam tentang bagaimana Anda dapat mencapai pencocokan pola yang tangguh dan type-safe dalam proyek JavaScript dan TypeScript Anda, menghilangkan seluruh kelas bug sebelum kode Anda dijalankan.
Apa Sebenarnya Pencocokan Pola Itu?
Pada intinya, pencocokan pola adalah mekanisme untuk memeriksa sebuah nilai terhadap serangkaian pola. Ini seperti pernyataan switch yang super canggih. Alih-alih hanya memeriksa kesetaraan dengan nilai sederhana (seperti string atau angka), pencocokan pola memungkinkan Anda untuk memeriksa terhadap struktur atau bentuk data Anda.
Bayangkan Anda sedang menyortir surat fisik. Anda tidak hanya memeriksa apakah amplop itu untuk "John Doe". Anda mungkin menyortir berdasarkan pola yang berbeda:
- Apakah ini amplop kecil persegi panjang dengan prangko? Mungkin ini surat biasa.
- Apakah ini amplop besar yang empuk? Kemungkinan ini adalah paket.
- Apakah ada jendela plastik bening? Hampir pasti ini adalah tagihan atau surat resmi.
Pencocokan pola dalam kode melakukan hal yang sama. Ini memungkinkan Anda menulis logika yang mengatakan, "Jika data saya terlihat seperti ini, lakukan itu. Jika memiliki bentuk ini, lakukan sesuatu yang lain." Gaya deklaratif ini membuat niat Anda jauh lebih jelas daripada jaringan pemeriksaan imperatif yang kompleks.
Masalah Klasik: Pernyataan `switch` yang Tidak Aman
Mari kita mulai dengan skenario umum di JavaScript. Kita sedang membangun aplikasi grafis dan perlu menghitung luas berbagai bentuk. Setiap bentuk adalah objek dengan properti `kind` untuk memberitahu kita apa itu.
// Objek-objek bentuk kita
const circle = { kind: 'circle', radius: 5 };
const square = { kind: 'square', sideLength: 10 };
const rectangle = { kind: 'rectangle', width: 4, height: 8 };
function getArea(shape) {
switch (shape.kind) {
case 'circle':
// MASALAH: Tidak ada yang menghentikan kita mengakses shape.sideLength di sini
// dan mendapatkan `undefined`. Ini akan menghasilkan NaN.
return Math.PI * shape.radius ** 2;
case 'square':
return shape.sideLength ** 2;
case 'rectangle':
return shape.width * shape.height;
}
}
Kode JavaScript murni ini berfungsi, tetapi rapuh. Ia memiliki dua masalah utama:
- Tidak Ada Keamanan Tipe (Type Safety): Di dalam kasus `'circle'`, runtime JavaScript tidak tahu bahwa objek `shape` dijamin memiliki properti `radius` dan bukan `sideLength`. Kesalahan ketik sederhana seperti `shape.raduis` atau asumsi yang salah seperti mengakses `shape.width` akan menghasilkan
undefineddan menyebabkan kesalahan runtime (sepertiNaNatauTypeError). - Tidak Ada Pemeriksaan Kelengkapan (Exhaustiveness Checking): Apa yang terjadi jika seorang pengembang baru menambahkan bentuk `Triangle`? Jika mereka lupa memperbarui fungsi `getArea`, fungsi tersebut hanya akan mengembalikan `undefined` untuk segitiga, dan bug ini mungkin tidak terdeteksi sampai menyebabkan masalah di bagian lain aplikasi. Ini adalah kegagalan senyap, jenis bug yang paling berbahaya.
Solusi Bagian 1: Fondasi dengan Discriminated Union TypeScript
Untuk mengatasi masalah ini, pertama-tama kita perlu cara untuk mendeskripsikan "data yang bisa menjadi salah satu dari beberapa hal" ke sistem tipe. Discriminated Union dari TypeScript (juga dikenal sebagai tagged union atau algebraic data types) adalah alat yang sempurna untuk ini.
Sebuah discriminated union memiliki tiga komponen:
- Satu set interface atau tipe berbeda yang mewakili setiap varian yang mungkin.
- Properti literal yang sama (diskriminan) yang ada di semua varian, seperti `kind: 'circle'`.
- Tipe union yang menggabungkan semua varian yang mungkin.
Membangun `Shape` Discriminated Union
Mari kita modelkan bentuk-bentuk kita menggunakan pola ini:
// 1. Definisikan interface untuk setiap varian
interface Circle {
kind: 'circle'; // Diskriminan
radius: number;
}
interface Square {
kind: 'square'; // Diskriminan
sideLength: number;
}
interface Rectangle {
kind: 'rectangle'; // Diskriminan
width: number;
height: number;
}
// 2. Buat tipe union
type Shape = Circle | Square | Rectangle;
Dengan tipe `Shape` ini, kita telah memberitahu TypeScript bahwa sebuah variabel dengan tipe `Shape` harus berupa `Circle`, `Square`, atau `Rectangle`. Tidak bisa yang lain. Struktur ini adalah dasar dari pencocokan pola type-safe.
Solusi Bagian 2: Type Guard dan Pemeriksaan Kelengkapan oleh Compiler
Sekarang setelah kita memiliki discriminated union, analisis alur kontrol TypeScript dapat menunjukkan keajaibannya. Ketika kita menggunakan pernyataan `switch` pada properti diskriminan (`kind`), TypeScript cukup pintar untuk mempersempit tipe di dalam setiap blok `case`. Ini berfungsi sebagai type guard otomatis yang kuat.
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
// TypeScript tahu `shape` adalah `Circle` di sini!
// Mengakses shape.sideLength akan menjadi kesalahan waktu kompilasi.
return Math.PI * shape.radius ** 2;
case 'square':
// TypeScript tahu `shape` adalah `Square` di sini!
return shape.sideLength ** 2;
case 'rectangle':
// TypeScript tahu `shape` adalah `Rectangle` di sini!
return shape.width * shape.height;
}
}
Perhatikan peningkatan langsungnya: di dalam `case 'circle'`, tipe dari `shape` dipersempit dari `Shape` menjadi `Circle`. Jika Anda mencoba mengakses `shape.sideLength`, editor kode Anda dan compiler TypeScript akan segera menandainya sebagai kesalahan. Anda telah menghilangkan seluruh kategori kesalahan runtime yang disebabkan oleh pengaksesan properti yang salah!
Mencapai Keamanan Sejati dengan Pemeriksaan Kelengkapan
Kita telah menyelesaikan masalah keamanan tipe, tetapi bagaimana dengan kegagalan senyap ketika kita menambahkan bentuk baru? Di sinilah kita memberlakukan pemeriksaan kelengkapan. Kita memberitahu compiler: "Anda harus memastikan bahwa saya telah menangani setiap varian yang mungkin dari tipe `Shape`."
Kita bisa mencapai ini dengan trik cerdas menggunakan tipe `never`. Tipe `never` mewakili nilai yang seharusnya tidak pernah terjadi. Kita menambahkan kasus `default` ke pernyataan `switch` kita yang mencoba menetapkan `shape` ke variabel bertipe `never`.
Mari kita buat fungsi pembantu kecil untuk ini:
function assertNever(value: never): never {
throw new Error(`Unhandled discriminated union member: ${JSON.stringify(value)}`);
}
Sekarang, mari kita perbarui fungsi `getArea` kita:
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.sideLength ** 2;
case 'rectangle':
return shape.width * shape.height;
default:
// Jika kita telah menangani semua kasus, `shape` akan bertipe `never` di sini.
// Jika tidak, tipenya akan menjadi tipe yang belum ditangani, menyebabkan kesalahan waktu kompilasi.
return assertNever(shape);
}
}
Pada titik ini, kode terkompilasi dengan sempurna. Tapi sekarang, mari kita lihat apa yang terjadi ketika kita memperkenalkan bentuk `Triangle` baru:
interface Triangle {
kind: 'triangle';
base: number;
height: number;
}
// Tambahkan bentuk baru ke union
type Shape = Circle | Square | Rectangle | Triangle;
Seketika, fungsi `getArea` kita akan menunjukkan kesalahan waktu kompilasi di kasus `default`:
Argumen dengan tipe 'Triangle' tidak dapat ditetapkan ke parameter dengan tipe 'never'.
Ini revolusioner! Compiler sekarang bertindak sebagai jaring pengaman kita. Ia memaksa kita untuk memperbarui fungsi `getArea` untuk menangani kasus `Triangle`. Bug runtime yang senyap telah menjadi kesalahan waktu kompilasi yang jelas dan terang. Dengan memperbaiki kesalahan tersebut, kita menjamin logika kita lengkap.
function getArea(shape: Shape): number { // Sekarang dengan perbaikan
switch (shape.kind) {
// ... kasus lainnya
case 'rectangle':
return shape.width * shape.height;
case 'triangle': // Tambahkan kasus baru
return 0.5 * shape.base * shape.height;
default:
return assertNever(shape);
}
}
Setelah kita menambahkan `case 'triangle'`, kasus `default` menjadi tidak dapat dijangkau untuk `Shape` yang valid, tipe `shape` pada titik itu menjadi `never`, kesalahan hilang, dan kode kita sekali lagi lengkap dan benar.
Melampaui `switch`: Pencocokan Pola Deklaratif dengan Library
Meskipun pernyataan `switch` dengan pemeriksaan kelengkapan sangat kuat, sintaksnya masih bisa terasa sedikit bertele-tele. Dunia pemrograman fungsional telah lama menyukai pendekatan pencocokan pola yang lebih berbasis ekspresi dan deklaratif. Untungnya, ekosistem JavaScript menawarkan library yang sangat baik yang membawa sintaks elegan ini ke TypeScript, dengan keamanan tipe dan kelengkapan penuh.
Salah satu library paling populer dan kuat untuk ini adalah `ts-pattern`.
Refactoring dengan `ts-pattern`
Mari kita lihat bagaimana tampilan fungsi `getArea` kita saat ditulis ulang dengan `ts-pattern`:
import { match, P } from 'ts-pattern';
function getAreaWithTsPattern(shape: Shape): number {
return match(shape)
.with({ kind: 'circle' }, (c) => Math.PI * c.radius ** 2)
.with({ kind: 'square' }, (s) => s.sideLength ** 2)
.with({ kind: 'rectangle' }, (r) => r.width * r.height)
.with({ kind: 'triangle' }, (t) => 0.5 * t.base * t.height)
.exhaustive(); // Memastikan semua kasus ditangani, sama seperti pemeriksaan `never` kita!
}
Pendekatan ini menawarkan beberapa keuntungan:
- Deklaratif dan Ekspresif: Kode ini dibaca seperti serangkaian aturan, dengan jelas menyatakan "ketika input cocok dengan pola ini, jalankan fungsi ini."
- Callback yang Type-Safe: Perhatikan bahwa dalam `.with({ kind: 'circle' }, (c) => ...)`, tipe `c` secara otomatis dan benar diinferensikan sebagai `Circle`. Anda mendapatkan keamanan tipe penuh dan pelengkapan otomatis di dalam callback.
- Pemeriksaan Kelengkapan Bawaan: Metode `.exhaustive()` memiliki tujuan yang sama dengan pembantu `assertNever` kita. Jika Anda menambahkan varian baru ke union `Shape` tetapi lupa menambahkan klausa `.with()` untuk itu, `ts-pattern` akan menghasilkan kesalahan waktu kompilasi.
- Ini adalah Ekspresi: Seluruh blok `match` adalah sebuah ekspresi yang mengembalikan nilai, memungkinkan Anda untuk menggunakannya langsung dalam pernyataan `return` atau penetapan variabel, yang dapat membuat kode lebih bersih.
Kemampuan Lanjutan `ts-pattern`
`ts-pattern` jauh melampaui pencocokan diskriminan sederhana. Ini memungkinkan pola yang sangat kuat dan kompleks.
- Pencocokan Predikat dengan `.when()`: Anda dapat mencocokkan berdasarkan suatu kondisi.
- Pencocokan Wildcard dengan `P.any` dan `P.string` dll: Cocokkan pada bentuk objek tanpa diskriminan.
- Kasus Default dengan `.otherwise()`: Menyediakan cara yang bersih untuk menangani kasus apa pun yang tidak dicocokkan secara eksplisit, sebagai alternatif dari `.exhaustive()`.
// Tangani persegi besar secara berbeda
.with({ kind: 'square' }, (s) => s.sideLength ** 2)
// Menjadi:
.with({ kind: 'square' }, s => s.sideLength > 100, (s) => /* logika khusus untuk persegi besar */)
.with({ kind: 'square' }, (s) => s.sideLength ** 2)
// Cocokkan objek apa pun yang memiliki properti `radius` numerik
.with({ radius: P.number }, (obj) => `Found a circle-like object with radius ${obj.radius}`)
.with({ kind: 'circle' }, (c) => /* ... */)
.otherwise((shape) => `Unsupported shape: ${shape.kind}`)
Kasus Penggunaan Praktis untuk Audiens Global
Pola ini tidak hanya untuk bentuk geometris. Ini sangat berguna dalam banyak skenario pemrograman dunia nyata yang dihadapi pengembang di seluruh dunia setiap hari.
1. Menangani Status Permintaan API
Tugas umum adalah mengambil data dari API. Status permintaan ini biasanya bisa menjadi salah satu dari beberapa kemungkinan: awal, memuat, berhasil, atau gagal. Discriminated union sangat cocok untuk memodelkan ini.
interface StateInitial {
status: 'initial';
}
interface StateLoading {
status: 'loading';
}
interface StateSuccess {
status: 'success';
data: T;
}
interface StateError {
status: 'error';
error: Error;
}
type RequestState = StateInitial | StateLoading | StateSuccess | StateError;
// Di komponen UI Anda (mis., React, Vue, Svelte, Angular)
function renderComponent(state: RequestState) {
return match(state)
.with({ status: 'initial' }, () => Selamat datang! Klik tombol untuk memuat profil Anda.
)
.with({ status: 'loading' }, () => )
.with({ status: 'success' }, (s) => )
.with({ status: 'error' }, (e) => )
.exhaustive();
}
Dengan pola ini, mustahil untuk secara tidak sengaja me-render profil pengguna saat status masih memuat, atau mencoba mengakses `state.data` saat statusnya `error`. Compiler menjamin konsistensi logis dari UI Anda.
2. Manajemen State (mis., Redux, Zustand)
Dalam manajemen state, Anda mengirimkan action untuk memperbarui state aplikasi. Action ini adalah kasus penggunaan klasik untuk discriminated union.
type CartAction =
| { type: 'ADD_ITEM'; payload: { itemId: string; quantity: number } }
| { type: 'REMOVE_ITEM'; payload: { itemId: string } }
| { type: 'SET_SHIPPING_METHOD'; payload: { method: 'standard' | 'express' } }
| { type: 'APPLY_DISCOUNT_CODE'; payload: { code: string } };
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'ADD_ITEM':
// `action.payload` memiliki tipe yang benar di sini!
// ... logika untuk menambah item
return { ...state, /* item yang diperbarui */ };
case 'REMOVE_ITEM':
// ... logika untuk menghapus item
return { ...state, /* item yang diperbarui */ };
// ... dan seterusnya
default:
return assertNever(action);
}
}
Ketika tipe action baru ditambahkan ke union `CartAction`, `cartReducer` akan gagal dikompilasi sampai action baru ditangani, mencegah Anda lupa mengimplementasikan logikanya.
3. Memproses Event
Baik menangani event WebSocket dari server atau event interaksi pengguna dalam aplikasi yang kompleks, pencocokan pola menyediakan cara yang bersih dan dapat diskalakan untuk mengarahkan event ke handler yang benar.
type SystemEvent =
| { event: 'userLoggedIn'; userId: string; timestamp: number }
| { event: 'userLoggedOut'; userId: string; timestamp: number }
| { event: 'paymentReceived'; amount: number; currency: string; transactionId: string };
function processEvent(event: SystemEvent) {
match(event)
.with({ event: 'userLoggedIn' }, (e) => console.log(`User ${e.userId} logged in.`))
.with({ event: 'paymentReceived', currency: 'USD' }, (e) => handleUsdPayment(e.amount))
.otherwise((e) => console.log(`Unhandled event: ${e.event}`));
}
Ringkasan Manfaat
- Keamanan Tipe yang Andal: Anda menghilangkan seluruh kelas kesalahan runtime yang terkait dengan bentuk data yang salah (mis.,
Cannot read properties of undefined). - Kejelasan dan Keterbacaan: Sifat deklaratif dari pencocokan pola membuat niat pemrogram menjadi jelas, menghasilkan kode yang lebih mudah dibaca dan dipahami.
- Kelengkapan yang Terjamin: Pemeriksaan kelengkapan mengubah compiler menjadi mitra waspada yang memastikan Anda telah menangani setiap varian data yang mungkin.
- Refactoring yang Mudah: Menambahkan varian baru ke model data Anda menjadi proses yang aman dan terpandu. Compiler akan menunjukkan setiap lokasi di basis kode Anda yang perlu diperbarui.
- Mengurangi Boilerplate: Library seperti `ts-pattern` menyediakan sintaks yang ringkas, kuat, dan elegan yang seringkali jauh lebih bersih daripada pernyataan alur kontrol tradisional.
Kesimpulan: Rangkul Kepercayaan Waktu Kompilasi
Beralih dari struktur alur kontrol tradisional yang tidak aman ke pencocokan pola type-safe adalah sebuah pergeseran paradigma. Ini tentang memindahkan pemeriksaan dari waktu runtime, di mana mereka muncul sebagai bug bagi pengguna Anda, ke waktu kompilasi, di mana mereka muncul sebagai kesalahan yang membantu bagi Anda, sang pengembang. Dengan menggabungkan discriminated union TypeScript dengan kekuatan pemeriksaan kelengkapan—baik melalui pernyataan `never` manual atau library seperti `ts-pattern`—Anda dapat membangun aplikasi yang lebih kuat, mudah dipelihara, dan tangguh terhadap perubahan.
Lain kali Anda mendapati diri Anda menulis rantai `if-else if-else` yang panjang atau pernyataan `switch` pada properti string, luangkan waktu sejenak untuk mempertimbangkan apakah Anda dapat memodelkan data Anda sebagai discriminated union. Lakukan investasi dalam keamanan tipe. Diri Anda di masa depan, dan basis pengguna global Anda, akan berterima kasih atas stabilitas dan keandalan yang dibawanya ke perangkat lunak Anda.